<?php

declare(strict_types=1);

namespace Zms\Unit\Library;

use DOMDocument;
use DOMElement;
use DOMException;
use Exception;

/**
 * 将 PHP 数组转换为格式良好的 XML 字符串的工具类。
 *
 * 支持：
 * - 嵌套数组结构
 * - 元素属性（通过 `_attributes` 或 `@attributes`）
 * - CDATA 区块（通过 `_cdata` 或 `@cdata`）
 * - 原始 XML 片段插入（通过 `_mixed` 或 `@mixed`）
 * - 数值索引数组自动转为带前缀标签（如 numeric_0）
 * - 自动处理非法 XML 字符（控制字符、空格等）
 *
 * 示例：
 *   ArrayToXml::convert([
 *       'user' => [
 *           '_attributes' => ['id' => 1],
 *           'name' => 'Alice',
 *           'bio' => ['_cdata' => '<p>Hi!</p>']
 *       ]
 *   ]);
 *   // 输出：<user id="1"><name>Alice</name><bio><![CDATA[<p>Hi!</p>]]></bio></user>
 *
 * @author 竹林风 <zms-pro@qq.com>
 * @since 2025-11-24
 */
class ArrayToXml
{
    /**
     * 底层 DOMDocument 实例，用于构建 XML 结构。
     *
     * @var DOMDocument
     */
    protected $document;

    /**
     * 是否将数组键名中的空格自动替换为下划线。
     *
     * XML 标签名不允许包含空格，启用此选项可避免 DOM 异常。
     * 例如：'first name' → 'first_name'
     *
     * @var bool
     */
    protected $replaceSpacesByUnderScoresInKeyNames = true;

    /**
     * 数值索引数组转换时使用的标签前缀。
     *
     * 当数组键为数字（如 [0 => 'a', 1 => 'b']）时，
     * 会生成 <numeric_0>a</numeric_0>、<numeric_1>b</numeric_1>。
     *
     * 可通过特殊键 `__numeric` 显式触发此行为。
     *
     * @var string
     */
    protected $numericTagNamePrefix = 'numeric_';

    /**
     * 构造函数：从数组构建 XML 文档对象。
     *
     * @param array $array 要转换的源数组（必须是非纯索引的顶层结构）
     * @param string|array $rootElement 根元素配置：
     *                                                          - 字符串：根标签名（空则默认 'root'）
     *                                                          - 数组：['rootElementName' => 'xxx', '_attributes' => [...]]
     * @param bool $replaceSpacesByUnderScoresInKeyNames 是否将键名空格替换为下划线（默认 true）
     * @param string $xmlEncoding XML 编码（默认 'UTF-8'）
     * @param string $xmlVersion XML 版本（默认 '1.0'）
     * @param array $domProperties DOMDocument 属性配置（如 ['formatOutput' => true]）
     *
     * @throws DOMException 如果顶层 $array 是纯数值索引数组（无法生成合法 XML）
     * @throws Exception    如果 $domProperties 包含无效的 DOMDocument 属性
     */
    public function __construct(
        array  $array,
               $rootElement = '',
        bool   $replaceSpacesByUnderScoresInKeyNames = true,
        string $xmlEncoding = 'UTF-8',
        string $xmlVersion = '1.0',
        array  $domProperties = []
    )
    {
        // 初始化 DOMDocument，设置版本和编码
        $this->document = new DOMDocument($xmlVersion, $xmlEncoding);

        // 应用用户自定义的 DOMDocument 属性（如格式化输出）
        if (!empty($domProperties)) {
            $this->setDomProperties($domProperties);
        }

        // 存储配置选项
        $this->replaceSpacesByUnderScoresInKeyNames = $replaceSpacesByUnderScoresInKeyNames;

        // 验证顶层不能是纯数值索引数组（如 [1,2,3]），否则无法生成有意义的 XML
        if ($this->isArrayAllKeySequential($array) && !empty($array)) {
            throw new DOMException('Top-level array cannot be a sequential numeric array (e.g., [1,2,3]). Use associative keys or wrap in an associative array.');
        }

        // 创建根节点并挂载到文档
        $root = $this->createRootElement($rootElement);
        $this->document->appendChild($root);

        // 递归转换数组为 XML 节点
        $this->convertElement($root, $array);
    }

    /**
     * 获取最终生成的 XML 字符串。
     *
     * @return string 完整的 XML 文档字符串（包含 <?xml ... ?> 声明）
     */
    public function parse(): string
    {
        return $this->document->saveXML();
    }

    /**
     * 验证给定的属性是否为 DOMDocument 的有效公共属性。
     *
     * 用于防止用户传入非法属性名（如拼写错误）。
     *
     * @param array $domProperties 待验证的属性键值对
     * @throws Exception 当存在无效属性时抛出异常
     */
    protected function ensureValidDomProperties(array $domProperties)
    {
        foreach ($domProperties as $key => $value) {
            if (!property_exists($this->document, $key)) {
                throw new Exception("'$key' is not a valid property of DOMDocument.");
            }
        }
    }

    /**
     * 设置 DOMDocument 的运行时属性。
     *
     * 常用于控制输出格式，例如：
     *   ['formatOutput' => true]      // 美化缩进
     *   ['preserveWhiteSpace' => false]
     *
     * @param array $domProperties 属性映射
     * @return $this 支持链式调用
     * @throws Exception 当属性名无效时
     */
    private function setDomProperties(array $domProperties)
    {
        $this->ensureValidDomProperties($domProperties);

        foreach ($domProperties as $key => $value) {
            $this->document->{$key} = $value;
        }

        return $this;
    }

    /**
     * 递归地将数组值转换为 XML 节点并附加到指定元素。
     *
     * 处理以下情况：
     * - 普通标量值：转义后设为 nodeValue
     * - 关联数组：解析特殊键（_attributes, _cdata 等）
     * - 索引数组：视为同名子元素集合
     *
     * @param DOMElement $element 要附加子内容的父元素
     * @param mixed $value 待转换的值（通常为数组或标量）
     */
    private function convertElement(DOMElement $element, $value)
    {
        // 判断当前数组是否为纯数值索引（即 [0=>..., 1=>...] 形式）
        $sequential = $this->isArrayAllKeySequential($value);

        // 标量值：直接设为文本内容
        if (!is_array($value)) {
            // 转为字符串并进行 HTML/XML 转义（防止注入）
            $value = htmlspecialchars((string)$value, ENT_QUOTES | ENT_XML1, 'UTF-8');
            // 移除 XML 不允许的控制字符（U+0000 ~ U+001F，除 \t\n\r）
            $value = $this->removeControlCharacters($value);
            $element->nodeValue = $value;
            return;
        }

        // 遍历数组每一项
        foreach ($value as $key => $data) {
            if (!$sequential) {
                // —————— 关联数组分支 ——————
                if (($key === '_attributes') || ($key === '@attributes')) {
                    // 设置元素属性
                    $this->addAttributes($element, $data);
                } elseif ((($key === '_value') || ($key === '@value')) && is_string($data)) {
                    // 显式设置文本内容（覆盖默认行为）
                    $element->nodeValue = htmlspecialchars($data, ENT_QUOTES | ENT_XML1, 'UTF-8');
                } elseif ((($key === '_cdata') || ($key === '@cdata')) && is_string($data)) {
                    // 添加 CDATA 区块（不转义内容）
                    $element->appendChild($this->document->createCDATASection($data));
                } elseif ((($key === '_mixed') || ($key === '@mixed')) && is_string($data)) {
                    // 插入原始 XML 片段（需确保合法性！）
                    $fragment = $this->document->createDocumentFragment();
                    $fragment->appendXML($data); // 注意：此方法在旧版 libxml 中可能有安全风险
                    $element->appendChild($fragment);
                } elseif ($key === '__numeric') {
                    // 显式触发数值索引转换逻辑
                    $this->addNumericNode($element, $data);
                } else {
                    // 普通关联键：创建子元素
                    $this->addNode($element, $key, $data);
                }
            } else {
                // —————— 索引数组分支 ——————
                if (is_array($data)) {
                    // 子项仍是数组：视为同名元素集合（如 <item>...</item><item>...</item>）
                    $this->addCollectionNode($element, $data);
                } else {
                    // 子项为标量：添加为同名兄弟节点
                    $this->addSequentialNode($element, $data);
                }
            }
        }
    }

    /**
     * 处理显式的 __numeric 键，将数值数组转换为带前缀的命名元素。
     *
     * 用于绕过自动检测，强制将 [1,2,3] 转为 <numeric_0>1</numeric_0>...
     *
     * @param DOMElement $element 父元素
     * @param array $value 数值索引数组
     */
    protected function addNumericNode(DOMElement $element, array $value)
    {
        foreach ($value as $key => $item) {
            // 递归转换：构造一个单元素关联数组 [prefix_key => item]
            $this->convertElement($element, [$this->numericTagNamePrefix . $key => $item]);
        }
    }

    /**
     * 添加普通子节点（来自关联数组的键值对）。
     *
     * @param DOMElement $element 父元素
     * @param string|int $key 键名（将作为标签名）
     * @param mixed $value 值（可为标量或嵌套数组）
     */
    protected function addNode(DOMElement $element, $key, $value)
    {
        // 处理键名中的空格（XML 不允许）
        if ($this->replaceSpacesByUnderScoresInKeyNames) {
            $key = str_replace(' ', '_', (string)$key);
        }

        // 创建子元素
        $child = $this->document->createElement((string)$key);
        $element->appendChild($child);

        // 递归填充内容
        $this->convertElement($child, $value);
    }

    /**
     * 处理索引数组中的嵌套数组（如同名元素集合）。
     *
     * 例如：['items' => [['id'=>1], ['id'=>2]]]
     * 生成：<items><item><id>1</id></item><item><id>2</id></item></items>
     *
     * @param DOMElement $element 父元素（如 <items>）
     * @param array $value 子数组（如 [['id'=>1]]）
     */
    protected function addCollectionNode(DOMElement $element, array $value)
    {
        // 若父元素尚无内容，直接复用（避免多一层嵌套）
        if ($element->childNodes->length === 0 && $element->attributes->length === 0) {
            $this->convertElement($element, $value);
            return;
        }

        // 否则创建新的同名兄弟节点
        $child = $this->document->createElement($element->tagName);
        $element->parentNode->appendChild($child);
        $this->convertElement($child, $value);
    }

    /**
     * 处理索引数组中的标量值（如同名文本节点集合）。
     *
     * 例如：['tag' => ['a', 'b']] → <tag>a</tag><tag>b</tag>
     *
     * @param DOMElement $element 当前元素（首次调用时会被复用）
     * @param mixed $value 标量值
     */
    protected function addSequentialNode(DOMElement $element, $value)
    {
        // 第一个值直接写入当前元素
        if (empty($element->nodeValue)) {
            $element->nodeValue = htmlspecialchars((string)$value, ENT_QUOTES | ENT_XML1, 'UTF-8');
            return;
        }

        // 后续值创建新兄弟节点
        $child = $this->document->createElement($element->tagName);
        $child->nodeValue = htmlspecialchars((string)$value, ENT_QUOTES | ENT_XML1, 'UTF-8');
        $element->parentNode->appendChild($child);
    }

    /**
     * 判断一个值是否为“纯数值索引数组”。
     *
     * 定义：所有键均为整数，且从 0 开始连续（PHP 默认行为）。
     * 特殊：若首键为 '__numeric'，视为非顺序（用于显式控制）。
     *
     * @param mixed $value 待检测的值
     * @return bool true 表示是纯数值索引数组
     */
    protected function isArrayAllKeySequential($value)
    {
        if (!is_array($value)) {
            return false;
        }

        if (count($value) <= 0) {
            return true; // 空数组视为顺序
        }

        // 显式标记 __numeric 的数组不应被当作普通顺序数组处理
        if (\key($value) === '__numeric') {
            return false;
        }

        // 检查所有键是否为整数
        $keysAreInts = array_unique(array_map('is_int', array_keys($value)));
        return $keysAreInts === [true];
    }

    /**
     * 为元素批量添加属性。
     *
     * @param DOMElement $element 目标元素
     * @param array $data 属性键值对（如 ['id' => 1, 'class' => 'test']）
     */
    protected function addAttributes(DOMElement $element, array $data)
    {
        foreach ($data as $attrKey => $attrVal) {
            // 强制转为字符串（DOM 要求）
            $element->setAttribute((string)$attrKey, (string)$attrVal);
        }
    }

    /**
     * 创建 XML 根元素。
     *
     * @param string|array $rootElement 配置（字符串或数组）
     * @return DOMElement 创建的根节点
     */
    protected function createRootElement($rootElement)
    {
        if (is_string($rootElement)) {
            // 字符串：直接作为标签名（空则默认 'root'）
            $rootElementName = $rootElement ?: 'root';
            return $this->document->createElement($rootElementName);
        }

        // 数组形式：提取名称和属性
        $rootElementName = $rootElement['rootElementName'] ?? 'root';
        $element = $this->document->createElement($rootElementName);

        // 支持两种属性键名风格
        foreach ($rootElement as $key => $value) {
            if ($key !== '_attributes' && $key !== '@attributes') {
                continue;
            }
            $this->addAttributes($element, $rootElement[$key]);
        }

        return $element;
    }

    /**
     * 移除 XML 1.0 中不允许的控制字符。
     *
     * 保留合法字符：\t (0x09), \n (0x0A), \r (0x0D)
     * 移除其余：0x00–0x08, 0x0B, 0x0C, 0x0E–0x1F, 0x7F
     *
     * @param string $value 输入字符串
     * @return string 清理后的字符串
     */
    protected function removeControlCharacters(string $value)
    {
        return preg_replace('/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F]/', '', $value);
    }
}